アップロードされた画像を自動的に最適化するワークフローを作ってみた
こんにちは、八木です。
以前Lambdaを使った画像最適化の記事を執筆しました。
今回はこのLambda関数を応用して、S3にアップロードされた画像を自動で最適化するワークフローを作成します。また、オブジェクトの一覧を確認できるように、DynamoDBテーブルにオブジェクト情報を保存します。
構成図は以下です。
まずS3バケットへ画像がアップロードされたらEventBridgeでイベント検出し、Step Functionsのワークフローを実行します。
ワークフロー内では、まずDynamoDBテーブルにオブジェクトのレコードを作成します。続いてLambda関数でS3からオリジナルの画像オブジェクトを取得し、最適化した後、別のS3バケットに保存します。最後にDynamoDBテーブルのレコードを処理完了のステータスに更新します。
なお、S3へのオブジェクトアップロードをトリガーにLambda関数を実行するには、S3イベント通知から直接Lambda関数を実行することもできます。
しかし、今回のようにStep Functionsと組み合わせるなど、拡張を行いたい場合は、EventBridgeを使うと良いでしょう。
作ってみた
ということで早速作ってみました。
S3バケットの作成
オリジナルの画像を保存するソースバケットと、最適化された画像を保存するターゲットバケットをそれぞれ作成します。
今回はデフォルトの暗号化を無効にした状態で作成しました。
続いてソースバケットでEventBridgeへのイベント通知を有効にします。これを行わなければ、EventBridgeでルールを設定しても、S3がイベントを作成してくれません。
ソースバケット詳細画面の「プロパティ」タブに存在する「イベント通知」で設定を変更します。Amazon EventBridgeの編集から通知をオンにします。
DynamoDBテーブルの作成
オブジェクトのレコードを保存するDynamoDBテーブルを作成します。
テーブル名を optimizedImages
、パーティションキーを objectKey
(文字列型)としました。
Lambda関数の作成
画像最適化を行うLambda関数を作成します。
まず必要なライブラリをインストールします。
npm i -D typescript @types/aws-lambda @types/node esbuild
npm i @squoosh/lib @aws-sdk/client-s3
そして本命、最適化を行うLambda関数のコードは以下になります。
import { GetObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3' import { Context, S3ObjectCreatedNotificationEvent } from 'aws-lambda' import { Readable, Stream } from 'stream' import { cpus } from 'os' const squoosh = require('@squoosh/lib') const client = new S3Client({}) // Lambda関数のエントリポイント export const handler = async ( event: S3ObjectCreatedNotificationEvent, context: Context ) => { const object = await getObject( event.detail.bucket.name, event.detail.object.key ) if (!object) { return } const buf = await streamToBuffer(object as Readable) const optimized = await optimizeImage(buf) if (!process.env.TARGET_BUCKET) { throw new Error( 'expected environment variable "TARGET_BUCKET" does not exit.' ) } await putObject(process.env.TARGET_BUCKET, event.detail.object.key, optimized) } // S3からオブジェクトを取得する const getObject = async (bucketName: string, objectKey: string) => { const getCommand = new GetObjectCommand({ Bucket: bucketName, Key: objectKey, }) const res = await client.send(getCommand) return res.Body } // S3へオブジェクトを保存する const putObject = async ( bucketName: string, objectKey: string, image: Uint8Array ) => { const outputCommand = new PutObjectCommand({ Bucket: bucketName, Key: objectKey, Body: image, }) await client.send(outputCommand) } // Stream型をUint8Array型に変換する const streamToBuffer = async (stream: Stream): Promise<Uint8Array> => { return await new Promise((resolve, reject) => { const chunks: Uint8Array[] = [] stream.on('data', (chunk: Uint8Array) => { return chunks.push(chunk) }) stream.on('error', reject) stream.on('end', () => resolve(Buffer.concat(chunks))) }) } // Squooshを使って画像を最適化する const optimizeImage = async (original: Uint8Array): Promise<Uint8Array> => { const imagePool = new squoosh.ImagePool(cpus().length) const image = imagePool.ingestImage(original) const encodeOptions = { mozjpeg: 'auto', } await image.encode(encodeOptions) const encoded = await image.encodedWith.mozjpeg await imagePool.close() return encoded.binary }
くわしい処理の説明は以前の記事を参照ください。
ハイライトしている部分が以前の記事からの変更点です。
まずソースバケット名およびオブジェクトのキーを S3ObjectCreatedNotificationEvent
型のパラメータから取得しています。
ここに入力されるパラメータは後程Step Functionsで定義しますが、EventBridgeのパラメータをそのまま渡すようにします。
2つ目の変更点はターゲットバケット名です。ターゲットバケット名はLambda関数の環境変数に設定します。
続いてLambda関数のデプロイを行います。
まずLambda関数にアタッチするIAMロールをマネジメントコンソールで作成します。
信頼されたエンティティタイプはAWSのサービス、ユースケースにLambdaを選択します。
許可を追加では AWSLambdaExecute
を選択し、ロール名は lambdaImageOptimizer
としました。
続いてLambda関数の作成です。
まずは先ほどのコードをビルドし、zipファイルにまとめます。
npx esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js && \ cp node_modules/@squoosh/lib/build/*.wasm dist && \ cd dist && \ zip -r index.zip *
今回作成したLambda関数には、squooshライブラリに含まれているwasmファイルが必要なため、一緒にzipファイルにまとめています。
最後にAWS CLIからLambda関数を作成します。
aws lambda create-function \ --function-name image-optimizer \ --runtime "nodejs16.x" \ --role <iam_role_arn> \ --zip-file "fileb://index.zip" \ --handler index.handler \ --memory-size 2048 \ --timeout 300 \ --environment "Variables={TARGET_BUCKET=<target_bucket_name>}"
iam_role_arn
には先ほど作成したIAMロールのARN、 target_bucket_name
にはS3のターゲットバケット名を設定します。
ここで注意したいのが、 target_bucket_name
には絶対にソースバケット名を指定しないことです。
ターゲットバケット名にソースバケット名を設定すると何が起こるのか?それは無限ループです。
ソースバケットに画像をアップロード→EventBridgeがStep Functionsを起動→Step FunctionsがLambda関数を実行→Lambda関数が画像を最適化しソースバケットにアップロード→(以降無限ループ)
上記のような動作となり、無限にお金が溶けていきます。十分気をつけましょう。
安全策として、Lambda関数内で「ソースバケット名とターゲットバケット名が同じ場合、オブジェクトをアップロードしない」といった処理を入れた方が良いかもしれません。
これでLambda関数が作成できました。
Step Functionsステートマシンの作成
続いてLambda関数の呼び出しおよびDynamoDBへの記録を行うステートマシンを作成します。
以下のASL(Amazon State Language)でステートマシンを作成します。
なお、lambda_function_arn
の部分は作成したLambda関数のARNを指定します。
{ "StartAt": "DynamoDB PutItem", "States": { "DynamoDB PutItem": { "Type": "Task", "Resource": "arn:aws:states:::dynamodb:putItem", "Parameters": { "TableName": "optimizedImages", "Item": { "objectKey": { "S.$": "$.detail.object.key" }, "eventTime": { "S.$": "$.time" }, "status": { "S": "processing" } } }, "Next": "Lambda Invoke", "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "Fail" } ], "ResultPath": null }, "Lambda Invoke": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "Payload.$": "$", "FunctionName": "<lambda_function_arn>:$LATEST" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Next": "DynamoDB UpdateItem", "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "Fail" } ], "ResultPath": null }, "DynamoDB UpdateItem": { "Type": "Task", "Resource": "arn:aws:states:::dynamodb:updateItem", "Parameters": { "TableName": "optimizedImages", "Key": { "objectKey": { "S.$": "$.detail.object.key" }, "eventTime": { "S.$": "$.time" } }, "UpdateExpression": "SET #st = :val", "ExpressionAttributeNames": { "#st": "status" }, "ExpressionAttributeValues": { ":val": { "S": "done" } } }, "End": true, "Catch": [ { "ErrorEquals": [ "States.ALL" ], "Next": "Fail" } ] }, "Fail": { "Type": "Fail" } } }
これでステートマシンの作成は完了です。
EventBridgeルールの作成
最後にS3オブジェクトのアップロードを検出し、Step Functionsを実行するEventBridgeルールを作成します。
これが作成されると、画像のアップロードから自動的にワークフローが実行されます。
イベントバスをdefaultとし、以下のイベントパターンを設定します。
source_bucket_name
には作成したソースバケット名を指定します。
{ "source": ["aws.s3"], "detail-type": ["Object Created"], "detail": { "bucket": { "name": ["<source_bucket_name>"] } } }
ここでまた注意です。バケット名は必ずソースバケットのみを指定してください。
ターゲットバケットも含むようにルールを作成してしまうと、先ほどの説明と同様に無限ループが発生します。
ご注意ください。
続いてターゲットには作成したStep Functionsステートマシンを選択し、ルールを作成します。
検証
全ての設定が完了しました。実際にS3のソースバケットに画像をアップロードし、自動的に処理が行われるかを確認します。
ここで期待する動作は以下です。
- ソースバケットに画像がアップロードされる
- Step Functionsが実行される
- DynamoDBにオブジェクトに対応するレコードが作成される
- Lambdaによって画像が最適化され、ターゲットバケットに保存される
- DynamoDBのレコードが更新される
それでは試してみます。
S3のソースバケットに適当なjpeg画像をアップロードします。
するとEventBridgeがイベントを検出し、Step Functionsステートマシンが動き始めました。(画像は実行が完了した時のものです)
DynamoDBのテーブルを見てみると、レコードが作成されています。
実行が一瞬だったので、レコードの状態がstatus=processingのスクショは撮れませんでした…)
最後にS3のターゲットバケットにも最適化後の画像が保存されていました。
サイズ見てみると、ソースバケットにアップロードしたオリジナル画像が872.1KBだったのに対し、最適化後の画像は702.2KBになっていることがわかります。
画像のアップロードから最適化まで、自動で行われていることが確認できました。
最後に
今回は以前作成したLambda関数を応用して、S3への画像アップロードから画像最適化の自動ワークフローを作成しました。
今回行ったように、EventBridgeとStep Functionsを組み合わせると、さまざまな自動ワークフローを簡単に作ることができます。
定型作業で時間もったいないな〜と思っている作業があれば、ぜひこの機会に自動化を考えてみてはいかがでしょうか?
自動化を駆使して業務効率を上げていきましょう!
以上、全ての仕事を自動化したい、八木でした!